chapter1

您所在的位置:网站首页 risc v汇编语言入门 chapter1

chapter1

2023-12-02 09:35| 来源: 网络整理| 查看: 265

第一章.RISC-V体系结构 目录 1.1 RISC-V发展历史 1.2 RISC-V汇编语言 1.3 机器的特权状态 1.4 中断和中断处理 1.5 页式虚存管理 1.6 相关工具软件

本章的1.1节将简单介绍RISC-V的诞生和发展历史,其后将介绍和讨论RISC-V体系结构的各个方面,如1.2节介绍汇编语言、1.3节讨论机器状态、1.4节分析中断和中断处理机制、1.5节讨论分页机制。最后,在1.6节介绍PKE实验涉及的工具软件,以及它们的使用方法。值得注意的是,RISC-V的体系结构本身就是一个庞大的知识体系,实际上是很难用一章的内容完全讲清楚的(我们鼓励对该体系结构感兴趣的读者延申阅读参考文献开源指令集的指南以及RISC-V instruction set manual)。然而,不懂体系结构又无法理解操作系统和开展PKE的实验,所以本章将重点阐述和讨论与操作系统设计有关的知识内容,力求精简和突出重点。

1.1 RISC-V发展历史

RISC-V(读做“risk-five”)是一种典型的精简(Reduced Instruction Set Computer,简写为RISC)指令集(Instruction Set Architecture,简写为ISA),它是加利福尼亚大学伯克利分校的David Patterson教授与Krste Asanovic教授研究团队于2010年提出的一个开放指令集架构。名称中的“V”指的是该ISA的第五个版本,有时“V”也被解读为向量(Vector)扩展。与之前的诸多商用指令集(如x86、ARM,以及MIPS等)不同的是,RISC-V是一个开放指令集,它的提出受到了全球工业界和学术界的广泛关注。在我国,RISC-V的发展、研究和应用同样得到了广泛关注,在网信办、工信部、中科院等多个国家部委支持和指导下,中国开放指令生态(RISC-V)联盟于2018年11月8日浙江乌镇举行的第五届互联网大会上正式宣布成立。

相比于传统的商用指令集架构,RISC-V指令集以及其背后的计算机架构具有以下优点:

● 后发优势。RISC-V指令集的提出,是借鉴了之前的传统商业指令集,通过取长补短而得到的。相比于之前的指令集,RISC-V避免了很多前人踩过的非常多的坑。

● 开放架构。RISC-V指令集是一个开放的指令集,意味着任何组织或个人都可以自由地设计硬件,以支撑采用RISC-V指令所编写的代码,而不会被起诉或收到法院传票。同时,对该指令集的使用不受美国的贸易管制约束。需要指出的是,RISC-V指令是开放的,但采用该指令集的具体设计(IP)确是受到版权保护的,这样做的目的是在开放的前提下鼓励创新和保护开发者的商业利益。

● 场景丰富。RISC-V指令集的设计,综合考虑了多种应用场景(涵盖了从嵌入式低功耗应用到高性能服务器场景),为各种场景考虑并设计了多个不同但指令上接近的分支。在这一点上,RISC-V跟ARM类似,针对不同应用场景,RISC-V也都有对应版本。

● 开源参考。自RISC-V提出以来,工业界和学术界都积极开展了对其的应用和研究,出现了大量的开源实现(参见RISC-V开源项目列表)。这些开源实现,为具体应用领域的开发者提供了大量可借鉴的参考,同时也极大地降低了CPU的设计门槛。可以预见的是,随着大量基于该指令集的芯片开源项目的诞生,RISC-V的发展将像开源软件生态中的Linux那样,成为计算机芯片与系统领域创新的基石。

在本书所设计的实验中我们主要考虑通用计算场景,所以选择RV64G作为我们目标计算机的指令集。其中,RV64G中的“RV”代表RISC-V;“64”代表所支持的指令是64位的(实际上,交叉编译器在生成代码时可能会采用32位指令减小生成的目标代码长度),地址长度和寄存器长度都为64位;而“G”代表通用(general)计算平台。实际上,“G”等效于“IMAFD”,其中“I”代表整数(Integer)计算指令、整数load、整数store以及控制流(如分支跳转)指令,这些指令在任何RISC-V的实现中都是必须的;“M”代表乘法(Multiply),即平台支持乘法和除法运算;“A”代表原子(Atomic)扩展,支持对寄存器进行的原子读、修改和原子写操作,这些操作在多核设计中非常有用;“F”代表单精度浮点(Float)运算支持,“D”代表双精度浮点(Double)运算支持。

实际上,按照RISC-V的指令集规范,RISC-V的计算机可以设计为更低位数(如32位,甚至16位),也可以设计为更高位数(如128位),这些都取决于所设计的计算机所面向的目标领域。一般说来,如果计算机面向的目标领域是嵌入式领域,32位(甚至16位)就已经足够;如果目标领域为桌面的办公环境或者手机,64位应已足够;但如果目标领域为服务器,则可能128位的指令架构才是合适的。更高的指令集位数往往意味着更大规模的应用程序(更大的逻辑地址空间),以及管理更大规模的内存(注意:并不意味着更快的速度)。另外,指令集本身也是可以裁剪的,例如在为嵌入式环境设置的单核处理器上,我们可以只保留“IMF”而不支持“AD”,从而降低处理器设计的复杂度以及能耗。

考虑到今天64位电脑(x86_64)已广泛普及到桌面、服务器,我们通常接触到的操作系统(如Ubuntu、Windows10等)都是内置的64位编译器,编译出来的二进制代码(特别是我们将要用到的RISC-V模拟器)也都默认是64位,我们将PKE实验的目标平台类型设定为桌面电脑(采用通用版本RV64G)。

1.2 RISC-V汇编语言

关于RISC-V汇编语言,较好的中文参考资料有《RISC-V手册——一本开源指令集的指南》(参见开源指令集的指南),里面完整列举和解释了RISC-V的所有汇编指令,感兴趣的读者可以通过阅读该书了解RISC-V汇编。在这里,我们不打算详细讨论RISC-V的汇编知识,只重点讨论RISC-V汇编与8086汇编的不同,以及PKE的代码涉及到的一些汇编语法和指令。

1.2.1 寄存器

表1.1列出了采用RV64G指令集的RISC-V计算机中的32个通用寄存器。

表1.1 RV64G的32个通用寄存器(寄存器的宽度都是64位)

寄存器 编程接口名称 (ABI) 描述 使用 x0 zero Hard-wired zero 硬件零 x1 ra Return address 常用于保存(函数的)返回地址 x2 sp Stack pointer 栈顶指针 x3 gp Global pointer — x4 tp Thread pointer — x5-7 t0-2 Temporary 临时寄存器 x8 s0/fp Saved Register/ Frame pointer (函数调用时)保存的寄存器和栈顶指针 x9 s1 Saved register (函数调用时)保存的寄存器 x10-11 a0-1 Function argument/ return value (函数调用时)的参数/函数的返回值 x12-17 a2-7 Function argument (函数调用时)的参数 x18-27 s2-11 Saved register (函数调用时)保存的寄存器 x28-31 t3-6 Temporary 临时寄存器

需要注意的是,同一个通用寄存器可以用不同的名字来对它进行访问。例如x0和zero对应的是同一个寄存器,且该寄存器是一个硬件零(hardware zero),也就是从该寄存器中读出来的数据永远是零。x8、s0、fp这些符号,对应的也是同一个寄存器。一般来说,汇编语言在书写时往往采用表1.1中的编程接口名称(ABI)来实现对寄存器的访问,以提高汇编代码的可读性。

除了表1.1中的通用寄存器外,RV64G处理器中还有一个重要的寄存器:program counter(pc)。它也是一个64位寄存器,但对它的读取需要采用特殊指令(如auipc指令)进行。另外,RISC-V计算机为这些通用寄存器赋予了不同的用途。例如,sp(stack pointer)寄存器用于指向栈顶(准确地讲是栈的低地址端。相应地,我们将栈的高物理地址端称为栈底),fp(frame pointer)寄存器则会在发生函数调用时被赋值为sp的内容并压栈;字母a开始(argument register)的寄存器常用于函数调用时的参数,以及返回值的传递;字母s开始(save register)的寄存器用于在发生函数调用时保存重要计算结果;字母t开始(temporary register)的寄存器则往往用于不重要的中间结果的保存。需要注意的是,在RISC-V计算机中除了硬件零寄存器zero、栈顶指针sp、函数返回地址ra外,其余寄存器的使用实际上取决于汇编程序员或者编译器(如我们将要用到的RISC-V版本的GCC交叉编译器)。例如,对于s开始的寄存器,虽然RISC-V规范要求在调用函数的时候,由被调用的函数将它们都保存(到栈中),但实际的函数代码为了追求更高的速度,往往只保存被函数使用过的寄存器。这一点,我们将在1.2.5节的例子中给予展示。

不同于8086,RISC-V中无法对“半个”寄存器进行直接访问(例如AL之于AX),指令始终需要访问完整的寄存器。另外,对于栈的访问,RISC-V处理器也并未提供类似push或者pop等指令来完成,而是采用load/store来实现对内存中栈的访问。

1.2.2 指令格式

图1.1列出了RV64I(RV64G的整数指令子集)的常用基础指令。

图1.1 RV64I的常用基础指令,带下划线的粗体字母从左到右连起来构成RV64I指令。

从图1.1中,我们看到RV64I的常用基础指令并不多,分为整数指令(Integer computation)、访存指令(Load and Store)和控制转移指令(即跳转指令,Control transfer),每条指令的名称取图中下划线和标黑的字母构成。例如,最基础的加法指令为add,如果累加的其中一个操作数为立即数,则指令为addi。实际上,除了列出的指令外,RV64I还包括很多其他的指令,例如用于机器状态控制的指令、内存墙指令等。我们将在1.4节中讨论机器状态控制指令,在1.5节讨论与虚拟存储相关的指令。

RISC-V汇编指令的格式与8086汇编是截然不同的。以最基础的加法指令为例,它的指令格式(以加法指令add为例)为:

add rd, rs1, rs2

或者

addi rd, rs1, immediate

其中,rd代表目标寄存器(register destination),rs1和rs2分别代表源寄存器1(register source)和源寄存器2,immediate代表立即数。前一条指令的执行效果是将两个源寄存器(rs1和rs2)中所存储的值进行求和,并将结果写到rd寄存器;后一条指令的执行,将源寄存器1中的值与立即数相加,结果写到rd寄存器(忽略算术溢出)。需要注意的是,由于我们采用了64位指令集,意味着以上指令中所有的操作数(无论是rd还是rs中的值,以及immediate)都是64位的。如果immediate不足64位,则对其进行符号位(最高位)扩展到64位。如果要操作更小一点的数,则需要用到addw,它最后的w符号表示操作数为一个字(32位)。

以上我们只是以add汇编指令作为例子,举例说明了RISC-V汇编语言。读者会发现跟8086汇编语言相比,RISC-V汇编语言在语法上有着根本的区别,源和目的寄存器的位置是不一样的。由于本书是操作系统课程的实验指导,无法完整地列举和解释所有RISC-V汇编指令,我们强烈建议读者继续阅读参考文献中的开源指令集的指南,进一步了解RISC-V汇编语言。

1.2.3访存和寻址模式

在内存访问上,RISC-V提供的接口(相比于8086汇编)极其简单:读内存(load)和写内存(store)。而且所有其他指令都只与寄存器打交道,不能使用任何间接形式对内存进行访问。这样设计的好处是:避免了8086汇编里令人“眼花缭乱”的访存寻址模式,这也是RISC-V的重要“后发优势”之一。

我们以ld(load)指令和sw(store word)指令为例来说明RISC-V的访存指令,以下是ld指令的语法:

ld rd, offset(rs1)

执行ld指令的动作是将立即数offset进行符号扩展,与源寄存器rs1中的值进行求和,得到地址A,然后读出内存中A所对应地址的8个字节(64位),写到目的寄存器rd中。

以下是sw指令的语法:

sw rs2, offset(rs1)

它的作用是将立即数offset进行符号扩展,与源寄存器rs1中的值进行求和,得到地址A,然后将源寄存器rs2中的低位4字节(注意,不是8个字节,因为操纵的是word,即32位4字节)写到内存中以地址A开始的4个字节中。当RISC-V处理器采用s开始的指令(如例子中的sw、双字存储指令sd等)向内存中写入数据的时候,地址是从低端地址向高端地址发展的。同时,采用了小端对齐(little-endian)的字节序:当写入一个8字节的数值到内存中时,将该数值的低位部分放在低地址端。

以上的例子中,假设A=0x0000 0000 0000 0800,rs2寄存器中的低位4字节数值为0x1234ABCD,则sw指令执行完后内存中的数值为:

[0x0000 0000 0000 0800] = 0xCD [0x0000 0000 0000 0801] = 0xAB [0x0000 0000 0000 0802] = 0x34 [0x0000 0000 0000 0803] = 0x12

1.2.4 C语言内嵌汇编

为实现对硬件的“包装”,操作系统往往需要操纵硬件实现例如改变机器状态等动作,这些功能高级语言(如C)并未提供,往往需要汇编语言来完成。然而,纯粹用汇编语言来实现操作系统又会导致代码量太大,且代码的可读性不好。综合两者(汇编语言和C语言)长处的方案,是在C语言中实现汇编的嵌入,而PKE实验所选择的基于GCC的RISC-V交叉编译器就提供了两种形式的内嵌汇编的支持:基本内联汇编和扩展内联汇编。

● 基本内联汇编语句

基本行内汇编很容易理解,一般是按照下面的格式:

asm(“statements”);

该语句中“asm”也可以由“__asm__”来代替。在“asm”后面有时也会加上“__volatile__”表示编译器不要对括弧内的汇编代码进行任何优化,保持指令的原样。“asm”后面括号里面的便是汇编指令。例如:

asm(“li x17,81”); //将立即数81存入x17寄存器 asm(“ecall”); //调用ecall指令(作用类似8086上的int指令)

编译器碰到以上语句后,会将引号中的汇编语句直接翻译成对应的机器码,放到所生成的目标代码中。这两行代码的作用是调用81号软中断(即1.4节中的trap),但因为它们是相邻的汇编语句,所以可以用以下的形式书写:

asm(“li x17,81\n\t” “ecall”);`

也就是采用分隔符“\n\t”隔开多条汇编指令。对于编译器而言,以上两种写法是等效的。实际上GCC编译器在处理内联汇编语句时,是要把asm(…)的内容“打印”到汇编文件中,所以格式控制字符是必要的。

● 扩展内联汇编

扩展内联汇编使得嵌入在C语言中的代码能够带输入、输出参数,同时将被汇编代码块改变的寄存器“通知”给GCC编译器,作为后者在调度寄存器时的参考。扩展内联汇编的格式为:

asm volatile( "statements"(汇编语句模板): output_regs(输出部分): input_regs(输入部分): clobbered_regs(破坏描述部分) ) ;

其中asm 表示后面的代码为内嵌汇编,也可以写作__asm__;volatile 表示不想让编译器对里面的汇编代码进行优化,也可以写作__volatile__ 。"statements"是汇编语句模板;output_regs是内联汇编的输出部分,可以理解为将要被汇编语句修改的寄存器组;input_regs是内联汇编的输入部分,即语句执行所需要的输入寄存器;clobbered_regs是破坏描述部分,代表将要被汇编语句改变(破坏)的内容。扩展内联汇编中汇编语句模版是必须要的,其它3部分则都是可选内容,以下对这些部分分别进行解释:

汇编代码模板:汇编语句模板由汇编语句序列组成,语句之间使用“;”、“\n”或“\n\t”分开。指令中的操作数可以使用占位符引用C语言变量,操作数占位符最多10个,名称如下:%0,%1…,%9。指令中使用上述占位符表示的操作数,占位符从%0起,依次表示输出操作数、输入操作数。

输出部分:描述输出操作数,可以有多个约束,不同的操作数描述符之间用逗号格开,每个约束用“=”开头,接着用字母表示操作数的类型。常用约束有:"=r",表示相应操作数可以使用一个通用寄存器,"=m",表示操作数存放在内存单元中。例如:"=m" (ret),在这里的ret是最终存放输出结果的C程序变量,而“=m”则是限定字符串,限定字符串表示了对它之后的变量的限制条件,这样GCC就可以根据这些条件决定如何分配寄存器,如何产生必要的代码处理指令,以及如何处理操作数与C表达式或C变量之间的关系。“=”后面所跟的字母以及含义见表1.2。

输入部分:输入部分与输出部分相似,但是没有“=”符号。

破坏描述部分:破坏描述符用于通知编译器我们使用了哪些寄存器或内存, 可以防止内嵌汇编在使用某些寄存器时导致错误。修改描述符是由逗号隔开的字符串组成的,每个字符串描述一种情况,一般是寄存器,有时也会有“memory”。具体的意思就是告诉编译器在编译内嵌汇编的时候不能使用某个寄存器或者不能使用内存的空间。

表1.2 内联汇编常用约束

字母 含义 m、o 内存单元 r 动态分配的寄存器 i 立即数 f 浮点寄存器

我们来看一个C语言内嵌汇编代码的例子:

int dest=0;

int value=1;

asm volatile (

"lw t0,%1 \n\t"

"add t0,t0,t0 \n\t"

"sd t0,%0"

:"=m"(dest) //输出部分

:"m" (value) //输入部分

: "memory"); //破坏描述部分

在这个例子定义了两个局部变量dest和value,它们的初值分别为0和1。接下来的扩展内联汇编代码将value的值读入t0寄存器,作为输入;接着使用add指令使t0中的值自我相加(即t0=t0+t0);最后再将结果写回dest局部变量。需要注意的是,这里%0对应输出部分的dest,而%1对应着输入部分的value,因为dest在汇编代码模板之后先出现的是dest,后出现的是value。

1.2.5 一个例子

我们再看一个稍微大一点的例子,通过这个例子我们希望能尽量把以上的知识点串起来,加深读者对RISC-V汇编语言的理解。假设有以下C语言程序test_asm.c,其源代码如例1.1所示:

1 #include 2 3 void bar() 4 { 5 asm volatile( "li s5, 300" ); 6 } 7 8 int foo( int foo_arg ) 9 { 10 int x; 11 asm volatile( "li s5, 500" ); 12 bar(); 13 asm volatile ( 14 "sd s5,%0" 15 :"=m"(x) 16 : 17 : "memory"); 18 printf( "x=%d\n", x ); 19 return 10; 20 } 21 22 int main() 23 { 24 foo( 10 ); 25 return 0; 26 }

例1.1 test_asm.c代码列表

例1.1中的代码,除了main函数外,还定义了另外两个函数foo(int)和bar(),前者的返回值为整型而后者无返回值。主函数在执行过程中先调用foo(int)函数,而foo(int)函数在执行过程中调用bar()函数。代码在foo(int)函数中内嵌了两段汇编代码,第11行的基本汇编语句将寄存器s5的值赋值为500,而第13行至17行的扩展内联汇编则将寄存器s5的值读出到局部变量x,并在第18行打印出来。两端内嵌汇编之间被调用的bar()函数也执行了一个内嵌汇编语句,作用是将寄存器s5的值赋值为300。以上过程的目的在于验证s5寄存器在调用子函数的过程中,函数是否将寄存器的值进行了保留。按照RISC-V规范,对于名字为s开始的寄存器,在进行函数调用时应该对其值进行保留,如果这一点被严格遵守的话,例1.1在第18行的输出就应该输出500。

将例1.1采用RISC-V交叉编译器编译为二进制代码,使用以下命令行:

$riscv64-unknown-elf-gcc test_asm.c -march='rv64g' -o test

编译时我们通过GCC的“-march”开关指定目标指令集是RV64G。接下来采用以下命令执行例1.1中的代码(其中spike为模拟器,pke为我们的PKE内核,test为例1.1所对应的二进制代码):

$ spike pke test

以上命令的执行结果为“x=300”。

这说明编译器为了代码的效率,并未完全遵守RISC-V对寄存器使用的规范。我们可以对所生成的test文件进行反汇编:

$riscv64-unknown-elf-objdump -D ./test | less

得到以下输出:

00000000000101a4 : 101a4: ff010113 addi sp,sp,-16 101a8: 00813423 sd s0,8(sp) 101ac: 01010413 addi s0,sp,16 101b0: 12c00a93 li s5,300 101b4: 00000013 nop 101b8: 00813403 ld s0,8(sp) 101bc: 01010113 addi sp,sp,16 101c0: 00008067 ret 00000000000101c4 : 101c4: fd010113 addi sp,sp,-48 101c8: 02113423 sd ra,40(sp) 101cc: 02813023 sd s0,32(sp) 101d0: 03010413 addi s0,sp,48 101d4: 00050793 mv a5,a0 101d8: fcf42e23 sw a5,-36(s0) 101dc: 1f400a93 li s5,500 101e0: fc5ff0ef jal ra,101a4 101e4: ff543623 sd s5,-20(s0) 101e8: fec42783 lw a5,-20(s0) 101ec: 00078593 mv a1,a5 101f0: 0001c7b7 lui a5,0x1c 101f4: f7078513 addi a0,a5,-144 # 1bf70 101f8: 1e2000ef jal ra,103da 101fc: 00a00793 li a5,10 10200: 00078513 mv a0,a5 10204: 02813083 ld ra,40(sp) 10208: 02013403 ld s0,32(sp) 1020c: 03010113 addi sp,sp,48 10210: 00008067 ret 0000000000010214 : 10214: ff010113 addi sp,sp,-16 10218: 00113423 sd ra,8(sp) 1021c: 00813023 sd s0,0(sp) 10220: 01010413 addi s0,sp,16 10224: 00a00513 li a0,10 10228: f9dff0ef jal ra,101c4 1022c: 00000793 li a5,0 10230: 00078513 mv a0,a5 10234: 00813083 ld ra,8(sp) 10238: 00013403 ld s0,0(sp) 1023c: 01010113 addi sp,sp,16 10240: 00008067 ret

输出的第一列是我们的程序地址(也称逻辑地址);第二列是机器码,我们看到RV64G的机器码都是规整的32位(4个字节),这符合RISC指令集的特点;之后的列就是对应的汇编代码了。其中比较重要的指令是jal(jump and link),其作用是将返回地址(pc+4)写道ra寄存器中,且将pc赋值为第二个参数(即跳转到第二个参数所指向的逻辑地址)。另一个重要的指令是ret,其作用是将pc的赋值为ra寄存器的内容(即跳转到函数被调用之前保存的函数返回地址)。从反汇编的结果上来看,test中并未对s开始的寄存器进行任何的保护动作,这就是为什么我们得到“x=300”结果的原因。

我们仍然以例1.1为例分析RISC-V程序执行过程中,栈的变化。RISC-V并未提供push和pop指令,所以函数开始时对sp寄存器的操作都是RISC-V版本的入栈动作。例如,main函数中的“addi sp,sp,-16”指令,将sp减去16然后赋值给sp,其实质是在栈顶“空出”16个字节的空间,然后将必要的内容进行压栈。如后续的“sd ra,8(sp)”就是将返回地址(ra寄存器中的内容)压栈;而“sd s0,0(sp)”是将fp寄存器中的内容压栈(s0和fp是同一个寄存器,参见表1.1)。例1.1中程序从main函数到bar函数的函数调用过程中,它的程序栈将形成图1.2中所示的结构:

图1.2 例1.1程序的函数调用栈结构(从main函数到bar函数)

我们看到,不同的函数具有的栈帧(stack frame)的大小实际上是很不一样的,一般来说,简单的函数(如main和bar)的栈帧比较小都只有16个字节。这是因为对于函数来说,栈的作用在于保存进入时的现场、函数中被使用的寄存器,以及函数的局部变量。对于简单的函数而言,其使用的寄存器较少,同时也没啥局部变量,这些因素就导致了较小的栈帧。另外,这个函数调用过程中比较有趣的是ra和fp两个寄存器。ra寄存器指向函数的返回地址,一般会在函数入口处入栈。但是对于bar函数来说,因为它是叶子函数(leaf function)处于函数调用的最后一级,所以就无需将ra寄存器入栈。fp寄存器则指向上一级函数的栈帧,函数在入口处都需要将它存入栈中,因为只有这样函数才能够找到来时的路,在返回时做到有路可走。

1.3 机器的特权状态

采用RV64G指令集的处理器,定义了三种特权模式:运行在最高特权级的机器模式(machine mode),运行在次高特权级的监管者模式(supervisor mode),以及最低特权级的用户模式(user mode)。机器模式是启动后,计算机所处的模式,在该模式下能够运行所有特权指令,访问所有内存空间。监管者模式所处的特权级略低于机器模式,但仍能够运行部分特权指令,以及访问机器模式所规定的所有内存空间。用户模式的特权级最低,无法执行改变机器状态的特权指令,和访问超过应用程序范围的内存空间。为后续讨论的方便,我们有时会简称机器模式为M模式,监管者模式为S模式,用户模式为U模式。

实际上,RISC-V指令集的设计综合考虑了从微型嵌入式设备到云服务器的多种应用场合,根据应用场合的不同,就出现了多种特权级的组合。例如,对于简单的嵌入式设备(例如我们的U盘控制器)只设计机器模式(M)就足够了;对于有安全性考虑的嵌入式设备(例如车辆的中控、路由器等),则需要搭配机器模式和用户模式的组合(M+U);对于通用计算机(例如台式电脑、手机,以及云服务器),由于需要运行通用操作系统,则需要机器模式、监管模式以及用户模式的组合(M+S+U)。由于本书的主题是操作系统,在以后的实验中我们将假设我们的RISC-V计算机采用了M+S+U的组合。

图1.3 RISC-V机器的特权模式与特权级转换

通过《操作系统原理》课我们知道,现代的处理器定义不同特权级的根本原因,是为了对操作系统进行保护。例如,让操作系统运行在较高的特权模式,而用户代码则运行在较低的特权模式,以防止用户态代码执行恶意的动作破坏操作系统。然而,用户态的代码在它的生命周期里往往会要求做一些“合法”的特权模式行为(例如进行I/O,典型例子如常用的printf函数),这就意味着处理器同时需要支持特权模式的转换(control transfer)。在RISC-V处理器中,实现这种特权模式转换的工具就是中断,当执行在低特权模式的代码被中断时,处理器将进入更高特权模式执行中断处理例程来处理打断(低特权)代码执行的事件。中断处理完成后,处理器将从高特权模式返回低特权模式。这里的中断有时被称为异常或系统调用,我们将在1.4节详细讨论中断的分类、相关术语和处理过程。需要注意的是,RISC-V处理器可以实现跨特权模式的转换,例如从U模式直接进入M模式,或者从M模式返回U模式,这些转换的发生都取决于机器的状态寄存器的设置。

RISC-V为每一种特权模式定义了一组寄存器,用于控制机器的状态(status)以及实现状态的转换。对于操作系统而言,比较重要的是机器模式和监管模式的一组寄存器(Control and Status Registers,简写为CSRs),下面我们将分别讨论这两种模式下的CSR,以及状态转换的实现。需要说明的是,关于RISC-V机器的特权状态,RISC-V instruction set manual是一个比较好的文档,这里我们将重点讨论与操作系统相关的内容。

1.3.1 机器模式下的CSR

表1.3列出了在机器模式下,RV64G处理中控制机器状态和与状态转换的主要寄存器:

表1.3 RV64G机器模式下的CSR(它们的长度都是64位)

寄存器 作用 mscratch Machine Scratch。保存机器模式的栈顶指针,这一点在离开机器模式进入低特权级模式(如监管模式)时非常重要,因为一旦在低特权级模式发生异常,将可能会回到机器模式处理,这时机器模式需要有自己的栈来保存M模式下的执行所调用的函数参数和返回地址。 mstatus Machine Status。保存机器状态的寄存器。 mtvec Machine Trap Vector。指向中断处理函数的入口地址。 mepc Machine Exception PC。指向发生异常的那条指令的地址。 mcause Machine Cause。发生中断的原因,如果发生的中断是异常,其最高位为0,低位为异常编号;如果发生的是其他类型的中断,则其最高位为1,低位为中断编号。 mtval Machine Trap Value。异常发生时,附带的参数值。例如,当缺页异常发生时,mtval的值就是程序想要访问的虚地址。 mie Machine Interrupt Enable。中断开启寄存器。 mip Machine Interrupt Pending。中断等待寄存器。 mideleg Machine Interrupt Delegation Registers。中断代理寄存器。 medeleg Machine Exception Delegation Registers。异常代理寄存器。

表1.3中的寄存器很多只是存放了一个指向内存地址的指针(如mscratch、mtvec、mepc),相对简单;而mcause、mtval、mideleg以及medeleg都与中断处理紧密相关,我们将在1.4节讨论中断时再对它们的作用进行讨论。M模式的CSR中比较特殊的是mstatus,它存放了机器的状态,其结构如下图所示:

图1.4 mstatus寄存器结构

以下是对mstatus中各个位的说明:

● MIE,SIE,UIE: 中断使能位。只有当这个位为1时,处理器才能够分别在机器模式、监管模式或者用户模式处理中断。实际上,处理器在M模式下处理中断,还要取决于对应特权模式的mie(即Machine Interrupt Enable)寄存器中对应的中断位是否设置,以及是否有中断产生(mip寄存器的值),即对应位是否被设置。RISC-V的中断并不一定非要在机器模式下处理,处理器可以将特定中断“代理”给其他特权模式(如监管模式或用户模式)处理,我们将在1.4节讨论这种中断代理的方法和相关寄存器(如表1.4中的mideleg和medeleg)的设置。

● MPIE,SPIE,UPIE:中断使能保存位。这些位的作用是保留或暂存MIE,SIE或UIE的值。例如,若中断发生且在机器模式处理,中断发生时MIE的值将自动保存到MPIE中,中断返回时,处理器将利用MPIE位的值恢复MIE位。

● SPP,MPP:发生中断异常之前的机器模式。MPP有两位,00表示用户模式,01表示监管模式,11则表示机器模式。这是因为机器模式具有最高特权级,当中断返回时(执行mret指令)从机器模式可以返回所有可能的模式。然而,具体返回到哪个模式,将将参考MPP的取值。例如,如果用户模式发生了中断,且中断例程在机器模式下执行,则进入中断时mstatus的MPP为00。在中断例程完成时,mret指令将让处理器返回到用户模式(目标模式保存在MPP中)。而SPP只有1位,因为从监管模式返回,可能到监管模式本身或者用户模式(不能返回机器模式)。

● FS,XS,SD:FS和XS这两个位,分别用于用户模式下的浮点扩展和内存页面标记扩展(脏数据等)。当处理器具备浮点运算单元,以及对应的用户态扩展时,这些位在mstatus中存在。根据它们的取值,处理器可以帮助操作系统判断在用户进程切换时是否需要保存上下文。例如,如果处理器认为操作系统在用户进程切换时需要保存更多的上下文(如浮点协处理器上的上下文,或内存中的页面),它将把SD位设置为1,否则为0。当然,如果处理器不具备浮点运算单元且不支持用户态扩展,则SD位的值恒定为0。

● MPRV,MXR,SUM:这几个位都与访存控制有关,关于RISC-V的页式内存管理机制将在1.5节讨论。MPRV(Modify PRiVilege)位用于控制load和store的访问权限,当MPRV=0时,load与store指令按照当前的特权模式进行地址转换与内存保护,当MPRV=1时,load和store将按照MPP中存储的特权模式的权限进行内存保护检查。例如发生中断前处理器处于U模式,中断后进入M模式处理,这时MPP的值为00。若此时MPRV=1,则在M模式的中断处理例程对内存访问的load和store依然按照U模式的权限进行。

MXR(Make eXecutable Readable)位用于修改load访存的权限。当MXR=0时,只能从标记为可读(页表项的R=1)的内存页面中读取数据;当MXR=1时,可以从标记为可读(页表项的R=1)或者可执行(页表项的X=1)的内存页面中读取数据。

SUM(permit Supervisor User Memory access)位用于控制S模式下的虚拟内存访问特权检查。当SUM=0时,系统将不允许S模式的代码对U模式下允许访问的页面的访问;当SUM = 1时,则允许这些访问。

● TVM(Trap Virtual Memory),TW(Timeout Wait),TSR(Trap SRET):这三个域用于RISC-V的虚拟化支持。虽然虚拟化和操作系统之间有着非常紧密的联系,但由于属于更高级的话题,在这里中不做详细论述。

● SXL,UXL:可以通过设置这两个域,改变低特权级下CSR的长度(如将它们设置为32位)。为了简化讨论,我们忽略这两个域,认为CSR长度为机器的总线宽度(全部为64位)。

1.3.2 监管模式下的CSR

与机器模式类似,RISC-V处理器在监管模式也定义了一组CSR,如表1.4所示。

表1.4 RV64G监管模式下的重要CSR

寄存器 作用 sscratch Supervisor Scratch。保存监管模式的栈顶指针,这一点在离开机器模式进入低特权级模式(如用户模式)时非常重要,因为一旦在低特权级模式发生异常,将可能会回到监管模式处理(假设已通过异常授权),这时监管模式需要用自己的栈保存程序执行的返回地址等。 sstatus Supervisor Status。保存监管状态的寄存器。 stvec Supervisor Trap Vector。指向监管模式中断处理函数的入口地址。 sepc Supervisor Exception PC。指向发生异常的那条指令的地址。 scause Supervisor Cause。发生中断的原因,如果发生的中断是异常,其最高位为0,低位为异常编号;如果发生的是其他中断其最高位为1,低位为中断编号。 stval Supervisor Trap Value。异常发生时,附带的参数值。例如,当缺页异常发生时,mtval的值就是程序想要访问的虚地址。 sie Supervisor Interrupt Enable。中断开启寄存器。 sip Supervisor Interrupt Pending。中断等待寄存器。 sideleg Supervisor Interrupt Delegation Registers。中断代理寄存器。 sedeleg Supervisor Exception Delegation Registers。异常代理寄存器。

这些寄存器的长度实际上取决于M模式下mstatus寄存器中的SXL位的值。但为了简化后续讨论,我们仍假设它们的长度为64位。

比较表1.3和1.4,我们发现S模式下的CSR和M模式下的CSR是一一对应的。实际上,它们的作用也是一一对应的关系,不同的地方在于S模式的特权级别比M模式稍低,这也体现在状态寄存器sstatus上,它的结构如图1.5所示:

图1.5 sstatus寄存器

相比于图1.4中的mstatus,图1.5中的sstatus像是一个“缩水”版的mstatus。sstatus中与mstatus相同的位具有与mstatus模式中相同的作用(见1.3.1节中的讨论),如UIE和SIE的作用仍然是控制是否允许在U模式或S模式处理中断,UPIE和SPIE仍然是发挥保存UIE和SIE位的作用,等等。除sstatus外,scause、stval、sideleg以及sedeleg的作用也与M模式下对应的mcause、mtval、mideleg以及medeleg类似,对它们的讨论也放在1.4节。

相应的,RISC-V处理器在U模式也定义了如表1.3或表1.4的一套CSR。U模式下CSR的作用也跟M模式或S模式类似,只是U模式是最低特权级模式,它们的作用比M或S模式下对应的CSR更简单。我们的PKE实验并未用到U模式下的CSR,所以我们不再对它们进行讨论。

1.3.3 CSR寄存器的读写指令

在RISC-V处理器上,对于CSR寄存器的读写不能使用普通的load指令,而应采用csr开头的汇编指令。表1.5列出了我们采用的RISC-V交叉汇编器(riscv64-unknown-elf-as)所支持的CSR读写指令。

表1.5 常用的CSR读写指令

指令 功能 csrr rd, csr Control and Status Register Read,读CSR指令。 把csr寄存器中的值写入到rd寄存器中。 csrw csr, rs1 Control and Status Register Write,写CSR指令。 把rs1的值写到csr寄存器中。 csrs csr, rs1 Control and Status Register Set,设置CSR指令。 对于rs1中每一个为1的位,将csr寄存器中对应位置位。 csrc csr, rs1 Control and Status Register Clear,清除CSR指令。 对于rs1中每一个为1的位,将csr寄存器中对应位清零。 csrrs rd, csr, rs1 Control and Status Register Read and Set,读后置位CSR指令。 记控制寄存器中的值为t,把t和寄存器rs1按位或的结果写入csr寄存器,再把t写入rd寄存器。 csrrc rd, csr, rs1 Control and Status Register Read and Clear,读后清除CSR指令。 记控制寄存器中的值为t,把t和寄存器rs1中的值按位与的结果写入csr寄存器,再把t写入rd寄存器。 csrrw rd, csr, rs1 Control and Status Register Read and Write,读后写CSR指令。 记控制寄存器中的值为t,把寄存器rs1的值写入csr寄存器,再把t写入rd寄存器。

需要注意的是,对于表1.5中的csrc、csrs、csrrs、csrrc以及csrrw指令,都有其立即数版本。例如csrci就是csrc的立即数版本,立即数版本将rs1替换为立即数,并取立即数的低5位参与运算。立即数版本的功能跟非立即数版本一致,但因为我们设计的实验(PKE)较少用到这类指令,所以我们并未把它们列入表1.5中。

1.4 中断和中断处理

通过上一节的讨论,我们知道中断是处理器在实现特权级之间 “穿越”,特别是从低特权模式向高特权模式进行控制转移的重要工具和手段。由于历史或翻译的原因,各种文献(特别是原理课的教材)中与中断相关的名词多且繁杂,然而对中断概念的理解又是操作系统中非常重要的一环。我们将在1.4.1节对中断的概念和分类进行讨论,力求厘清与中断相关的各种表述,帮助读者形成对中断的准确理解和表达。在1.4.3节,我们将讨论RISC-V处理器的中断处理,并在1.4.4节介绍RISC-V平台的中断代理机制。

1.4.1 中断的概念与分类

对于中断,我们必须认识到:它的本质是打断处理器上正在执行的程序,转而去执行另一段程序,并且在执行完这一段程序后,返回去执行原先在处理器上执行的之前被打断的程序的过程。例如,某程序在(低特权级模式的)处理器上执行时,由于某事件的发生,处理器不得不转而(进入更高特权级)执行该事件的处理程序,并在执行完处理程序后返回之前的(低特权级模式)程序继续执行。

在RISC-V处理器上,中断并不一定意味着特权级的变迁(RISC-V支持平级中断,例如简单嵌入式设备上从M模式到M模式的中断),但是对于传统意义的操作系统而言,由于多个特权级(例如我们考虑的M+S+U模式的RISC-V机器)的存在,中断处理例程往往在比用户模式(也就是被中断的用户程序所运行的特权模式)更高级别的特权模式中(如监管模式或机器模式)执行。对操作系统而言,这样做也可以更好地对操作系统本身,以及计算机的内存资源以及I/O资源进行保护。

实际系统中导致中断发生的事件往往是比较复杂的,它们的来源、处理时机和返回方式都不尽相同。为了便于读者对中断的理解以及表达的准确性,我们借鉴参考文献SiFive Interrupt的中断分类标准,将系统中发生的可能中断当前执行程序的事件分为3类:

● Exception(异常):这类中断是处理器在执行某条指令时,由于条件不满足而产生的。典型的异常有缺页、执行当前特权级不支持的指令等。相对于正在执行的程序而言,exception是同步(synchronous)发生的。exception产生的时机是指令执行的过程中(即处理器流水线的执行阶段),在exception处理完毕后,系统将返回发生exception的那条指令重新执行。 ● Syscall(系统调用):这类中断是当前执行的程序主动发出的ecall指令(类似8086中的int指令)导致的,即我们通常理解的“系统调用”。实际上,RISC-V术语中称这类中断为trap,但是我们认为trap并不准确,且强烈不建议把它翻译为“陷阱”!因为“陷阱”这个词在中文语境的含义甚至和“中断”一样宽泛,我们更推荐使用syscall(对应的中文翻译是“系统调用”)来指代这类中断。典型的syscall有屏幕输出(printf)、磁盘文件读写(read/write)等,这些高级语言函数调用通过系统函数库(libc)的转换,在RISC-V平台都会转换成ecall指令。与exception类似,相对于正在执行的程序而言,syscall也是同步(synchronous)发生的。但与exception不同的地方在于,syscall在处理完成后返回的是下一条指令。 ● IRQ(外部中断):这类中断一般是由外部设备产生的事件而导致的。实际上,RISC-V术语中称这类中断为interrupt,但我们认为在操作系统语境下用这个词并不合适,因为interrupt(特别是它的中文翻译“中断”)往往导致指代范围太宽泛的问题。IRQ(Interrupt ReQuest)来源于Intel的术语,我们认为,用它来指代外部中断更加准确和不容易导致混淆。典型的IRQ有:可编程时钟计时器(PIT)所产生的timer事件、DMA控制器发出的I/O完成事件、声卡发出的缓存空间用完事件等。相对于正在执行的程序而言,IRQ是异步(asynchronous)发生的。另外,对于处理器流水线而言,IRQ的处理时机是指令的间隙。不同于exception但与syscall类似,IRQ在处理完成后返回的是下一条指令。

表1.6 中断的分类

中断类型 产生时机 处理时机 返回地址 Exception(异常) 同步(于当前程序) 指令执行阶段 发生异常的指令 Syscall(系统调用) 同步(于当前程序) - 下一条指令 IRQ(外部中断) 异步(于当前程序) 指令执行间隙 下一条指令

表1.6对这3类中断进行了归纳和比较。需要注意的是,不同文献(特别是中文文献)对于某个类型的中断可能用了不同的名字,例如trap在很多文献和参考书中又被称为“陷阱”、“陷入”、“软件中断”或“系统调用”等等,具体指代往往不知所云。

面对纷繁复杂的术语,我们给读者的建议是:描述某个确定的中断时,尽量用英文单词来表达(不要翻译成中文,特别是避免使用“中断”或“陷入”这类含义太宽泛的名词)。实际上,当需要描述某个新类型的中断时,可以试着根据这个中断的产生、处理的时机,以及返回的位置来对它进行分类和归纳,并翻译成它对应的类名。这样,听众一听就知道该中断的类型以及对应的处理方式了,避免了很多不必要、啰嗦且含糊的解释。

1.4.2中断向量表

系统发生中断(我们用中文的“中断”这个名词来指代广义的中断,并非以上的interrupt)时执行的这段程序,往往被称为中断例程(interrupt routine)。因为事件的多样性,系统可能有多个这样的中断例程,通常的做法是把这些例程的入口放在一张表中,而这张表一般称为中断向量表(interrupt table)。RV64G处理器在发生中断后,会将发生的中断类型、编号自动记录(硬件完成)到目标模式的CSR中。假设发生中断的目标模式为M模式,则中断的这些信息会记录到mcause寄存器。表1.7列出了mcause的可能取值以及对应的中断信息(为简化讨论,我们只考虑为处理器配置本地中断控制器Core Local Interrupt即CLINT的情况SiFive Interrupt)。

表1.7 mcause寄存器的取值(由Interrupt及Code字段拼接)及值的含义

Interrupt Code 中断描述 1 0 User software interrupt 1 1 Superior software interrupt 1 2 保留 1 3 Machine software interrupt 1 4 User timer interrupt 1 5 Supervisor timer interrupt 1 6 保留 1 7 Machine timer interrupt 1 8 User external interrupt 1 9 Supervisor external interrupt 1 10 保留 1 11 Machine external interrupt 1 >=12 && =16 Implementation defined local interrupts 0 0 Instruction address misaligned 0 1 Instruction access fault 0 2 Illegal Instruction 0 3 Breakpoint 0 4 Load address misaligned 0 5 Load access fault 0 6 Store/AMO address misaligned 0 7 Store/AMO access fault 0 8 Environment call from U-mode 0 9 Environment call from S-mode 0 10 保留 0 11 Environment call from M-mode 0 12 Instruction page fault 0 13 Load page fault 0 14 保留 0 15 Store/AMO page fault 0 >=16 保留

从表1.7中,我们可以看到:首先,当发生的中断类型是interrupt时,mcause的高位为1,而如果发生的中断类型是exception(或trap)时,mcause的高位为0;其次,对于interrupt而言,一个特权模式只有一些可能的取值。例如,对于M模式,interrupt的code的可能(典型)取值和含义为:

● 3:Machine software interrupt。这种类型的中断是由软件产生的,但是却不是exception或者syscall!实际上,这类interrupt主要是指在多核(实际上,RISC-V中的硬件处理单元称作硬件线程,Hardware Threads简称Harts,一般情况下它等同于“处理器核”的概念)环境下,发生在处理器之间的中断。为了实现这类中断,RISC-V是通过让一个处理器核直接写另一个处理器核的本地中断控制器来实现的。

● 7:Machine timer interrupt。即时钟中断,它也是由处理器核所带的本地中断控制器产生的。

● 11:Machine external interrupt。即除了时钟中断之外的其他中断,例如键盘、其他外设等。

● >=16:Implementation defined local interrupts。与实现相关的其他中断源,对于RV64G指令集而言,这个数字可以到48(RV32G是16)。数字越大,意味着可以接更多的外部中断源。

最后,对于非interrupt(即表1.7的Interrupt=0)情况,Code=8时表示发生的是一个来自U模式的syscall(Environment call from U-mode);Code=9时表示发生的是一个来自S模式的syscall(Environment call from S-mode);而Code=11时表示发生的是一个来自M模式的syscall(Environment call from M-mode)。需要再次强调的是,这里的syscall就是我们平时所说的“系统调用”,在8086环境下它们由int指令产生,而在RISC-V环境下它们由ecall指令产生。

除了这几个取值外,表1.7中其他的Interrupt=0的情况就都是exception。例如,当Code=13(Load page fault)就是常见的所谓“缺页中断(实际上应该叫它缺页exception)”了;Code=15(Store/AMO page fault)就是访存(Store)或原子操作(AMO)异常,这些异常在我们的PKE实验中都能接触到,碰到这类异常往往意味着代码里存在写空指针错误。

仍然以机器模式为例,在RISC-V处理器中,中断向量表的组织和实现有两种方式,一种是直接模式(Direct Mode),另一种是向量模式(Vectored Mode)。前一种模式将CSR中的mtvec指向所有中断(包括表1.7中所有的interruption、trap和exception)的总入口函数,然后由该函数根据mcause中具体的值调用对应的中断例程。向量模式则严格按照表1.7所示的顺序,将所有中断例程的入口地址组织成一个向量(类似8086中的中断向量表),并将mtvec指向该表的首地址。当发生中断时,根据mcause中的值计算向量中的偏移并调用对应例程。RISC-V是根据mtvec的最低位(mtvec.mode)来判断系统具体采用了哪种模式:如果采用了直接模式,其最低位为0;如果采用了向量模式,则其最低位为1。在PKE实验中,我们在中断向量上使用的是直接模式(Direct Mode)。

1.4.3中断处理例程

当发生一个中断,假设其目标模式(即执行中断例程的模式)为机器模式,RISC-V处理器硬件将执行以下动作:

1)保存(进入中断处理例程之前的)pc(如果是syscall或者IRQ,则保存下一条指令的pc)到mepc寄存器;

2)将(进入中断处理例程之前的)特权级保存到mstatus寄存器的MPP字段;

3)将mstatus寄存器中的MIE字段保存到(它自己的)MPIE字段;

4)设置mcause,其值与表1.7中的Interrupt和Code值对应;

5)将pc设置为中断例程的入口,如果为直接模式则设置为mtvec的值;

6)将mstatus寄存器的MIE字段清零,转入机器模式。

在我们的PKE实验中,系统的中断实际上是代理给监管模式(S模式)处理的,在发生中断时处理器硬件的流程与以上的机器模式类似,只是mepc、mstatus、mcause以及mtvec换成了sepc、sstatus、scause以及stvec。

典型的中断处理流程如以下代码:

.align 6 .global handler_interrupt handler_interrupt: addi sp, sp, -32*REGBYTES STORE x1, 1* REGBYTES(sp) STORE x2, 2* REGBYTES(sp) ... STORE x31, 31* REGBYTES(sp) //call C code handler call software_handler //finished interrupt handling, ready to return LOAD x1, 1* REGBYTES(sp) LOAD x2, 2* REGBYTES(sp) ... LOAD x31, 31* REGBYTES(sp) addi sp, sp, 32*REGBYTES mret

在以上的代码中,中断例程将首先对通用寄存器进行保存(x0因为是硬件零,没有保存的价值),接下来调用由C语言编写的中断处理函数“software_handler”,在处理函数执行完毕后恢复通用寄存器的值,并调用mret(S模式下对应sret)指令返回。

处理器在执行mret指令时,将执行以下动作:

1)将mstatus寄存器的MPIE字段恢复到该寄存器的MIE字段;

2)处理器转换到mstatus寄存器中MPP字段所对应的特权模式;

3)将mepc中的内容恢复到pc中。

从以上中断例程的处理流程可以看到,中断的处理实际上是硬件和软件共同完成的。硬件负责保存一部分的中断现场,而软件则主要根据所发生中断的类型选择适合的中断例程来进行处理,软件部分也需要负责保存一部分的现场,如通用寄存器部分。

1.4.4 RISC-V的中断代理机制

RISC-V在中断处理上有一个很有意思的设计,就是可以将系统中的特定中断或者异常,通过设置较高特权级的CSR寄存器,“代理”给某个更低的特权级处理。例如,我们可以设置机器模式的mideleg以及medeleg中的某些位,将系统中的部分中断(对应mideleg)或异常(对应medeleg)“代理”给较低特权级的监管模式来处理;同理,我们也可以设置监管模式的sideleg以及sedeleg中的某些位,将系统中的部分中断或异常“代理”给用户特权级的代码来处理。

例如,我们的PKE代码中,有以下的等效代码:

csrw mideleg, 1


【本文地址】


今日新闻


推荐新闻


CopyRight 2018-2019 办公设备维修网 版权所有 豫ICP备15022753号-3